如何对 JS 引擎进行 JS 调试?本文以 V8 引擎为例,阐述如何搭建一个 JS 调试的 DevTools,并实现 V8 调试 JS 的能力,最后再对比不同的 JS 引擎在 JS 调试上的差异和调试方式。
一、DevTools 搭建
实现一个调试工具 DevTools,需要有呈现用户的调试器 Frontend,调试后端采集数据的 Backend,以及衔接 Frontend 与 Backend 之间的调试通道和协议。我们对 JS 的调试工具的实现可以如下所示:
1、调试器 Frontend 和调试协议
它们都会使用 Chrome DevTools 的 Frontend 和 Protocol,可以直接兼容移动端嵌入的 V8 引擎。区别在于移动端的 V8-Inspector 实现的协议是浏览器 Chromium 的子集。协议可见:https://chromedevtools.github.io/devtools-protocol/v8/:
相比 Chromium,V8 只实现了 Console、Source、Memory、CPU Profile 四个 Tab 的调试能力,当然其他的协议未实现是因为 V8 只是负责 JS 执行,不负责页面的渲染,所以对于其他的调试,我们可以在调试后端 Backend 上基于 CDP 的协议的基础上进行数据采集实现。
2、调试后端 Backend
App Backend 端接收 Chrome DevTools Protocol 调试消息,对于 V8 的支持的 CDP 协议直接转发给 Inspector,但对于不支持的 CDP 协议看情况可以采集数据进行适配。
3、调试通道
Chrome DevTools Frontend 会作为 websocket client 去连接外部的 websocket server,而连接的 websocket client url 也是外部 http 服务提供的,如下图所示:
在 Chrome 上设置 remote-debuging-port
,inspect
页面就会从 remote-debuging-port/json
http 服务上把所有的调试项展示出来,在点击某个 Target 进入 DevTools Frontend 时就会使用 ws 连接。
websocket server 可以直接设置在 App Backend 端,也可以放在 PC 本地服务上,甚至放在公网部署上。
- 本地调试:Android 使用
adb forward
/iOS 使用 usbmuxd 建立 pc/手机端的数据通信,当然也都可以通过无线局域网代理的方式通信。 - 公网调试:websocket server 部署在公网上,可以去掉 usb 线的连接或局域网代理的设置。
二、V8 概念
在了解 V8 调试之前,先来熟悉 V8 的一些概念:
Isolate
V8 的引擎实例,就是一个 JS 的虚拟机并且有自己的 heap,不同的 Isolate 之间不共享任何资源。一个 Isolate 可对应一个或多个线程,但在同一时刻只能被一个线程进入。
Context
JS 代码的执行环境,Context 中包含了 JavaScript 内建函数、对象等,不同的 Context 的 JavaScript 是沙箱隔离,默认不能互相访问,但可以通过 SetSecurityToken 设定安全令牌进行通信。需要注意的是一个 Isolate 同一时刻只能对应一个线程,那在多 Context 的场景下,也只有一个 Context 运行。
Handle
V8 的内存分配都是在 V8 的 heap 上分配的,方便对所有对象进行跟踪,Handle 是对 Heap 对象的引用,
Handle 分为 Local(局部)和 Persistent(全局)两种:
- Local Handle 使用
HandleScope
来管理 Persistent
使用Persistent::New()
和Persistent::Release()
来创建和释放。
HandleScope
HandleScope 用来管理 Handle 的容器,对 Handle 的创建和释放都可以通过 HandleScope 提供的 Handle stack 来管理
使用示例
1 | v8::HandleScope handle_scope(isolate); |
- 创建 HandleScope,内部是有一个 Handle Stack 来管理 Handle 的对象
- 创建一个本地的 Context,它分配在 Handle Stack 上,并指向 V8 heap 真实的 Context 对象
- 切换到当前 Context 环境上,在 Context 里编译和执行 JS 相关逻辑
三、V8 Inspector 调试实现
根据上边介绍的 V8 Isolate、Context 的概念,我们结合 Inspector 实现需要的类可用以下架构来实现:
Isolate
对应一个 V8 Engine,可同时支持多个 Context
业务 JS 执行,这里 Inspector
是跟 Isolate
一一对应的,那单个 Isolate
下的多个 Context
调试需要通过 Inspector
创建的 Session
和 Channel
来跟 DevTools Frontend 通信。
1、v8 调试框架实现
要实现 Inspector
需要传入 v8_inspector::V8InspectorClient
的实现类,我们用它来对 Inspector
进行创建和管理:
1 | // v8_inspector::V8InspectorClient impl |
- 创建
Inspector
:使用当前Isolate
和v8_inspector::V8InspectorClient
实现类 - 创建
Session
:使用v8_inspector::V8Inspector::Channel
的实现类和当前与 JS 执行的Context
关联的context_group_id
contextCreated
:使用前边创建的Session
的context_group_id
和Context
进行Session
关联,这样 Inspector 才能将某个Context
的调试消息分发给某个Session
dispatchProtocolMessage
:session
关联调试的context
后,就可以把 Frontend 的 CDP 调试协议消息转发给 V8contextDestroyed
:当 JS 环境的Context
销毁后把调试的Context
也就行销毁
对于转给 V8 消息的回包和 V8 的主动通知,都是通过传入 Session
的 v8_inspector::V8Inspector::Channel
实现来回调,我们可以在这两个函数里可以把消息转发给 Frontend:
1 | // v8_inspector::V8Inspector::Channel impl |
2、v8 调试还需要解决的问题
上边一顿操作后看起来可以与 Frontend 进行调试了,但在断点时会出现以下问题:
- 获取当前断点的对象属性,-32603,Internal error
- 获取当前指向的对象调用栈,直接报栈溢出 RangeError: Maximum call stack size exceeded
- 断点调试第一个断到后,下一步调试或step over 不生效
后面检查发现还需要在 v8_inspector::V8InspectorClient
实现类里对以下两个函数进行处理:
1 | void V8InspectorClientImpl::runMessageLoopOnPause(int contextGroupId) { |
runMessageLoopOnPause
在 V8 断点时触发,这时需要接入方同步消费 Frontend 传入的 CDP 消息,否则 V8 就丢失了这些消息,如运行时的对象获取等。在结束断点时触发 quitMessageLoopOnPause
,此时停止同步消费,恢复线程的正常执行。
四、V8 Inspector 的 JS 调试扩展
V8 Inspector 实现的协议只支持 Console/Sources/Memory/CPU Profile,但对于 Chrome DevTools Frontend 的其他调试如何扩展呢?
- Elements、Network、Application:这些与渲染框架有关,可以根据 CDP 协议,如需要适配 Elements 的 DOM/CSS/Page,采集渲染框架的数据返回给 Frontend 即可复用 DevTools Frontend 的调试部分。
- Performance:JS 的性能仍然需要去 V8 引擎采集,但 V8 Inspector 并未实现,此时需要根据 V8 暴露的头文件如
v8-tracing
采集对应的性能数据返回。
五、JS 引擎调试对比
我们对比业界 JS 引擎的调试,可以根据是否支持调试,调试限制条件这些整理如下:
JS引擎 | 是否实现的调试协议 | 调试接口暴露 | 调试限制 |
---|---|---|---|
V8 | ✅ | Inspector、Tracing 等 | 无限制,实现 Inspector 接口 |
JSC | ✅ | iOS 系统集成,未暴露 Debugger 相关 | 仅限开发者证书的包可调试 |
QuickJS | ❌ | 开源集成 | 无 |
- V8 引擎实现了 Inspector 相关的调试协议,业务接入只需要按本文讲到的方式实现即可支持 Chrome DevTools Frontend 来调试。
- JSC 引擎会被 iOS 系统以 Framework 集成,在携带设备 UUID 的开发者证书编译的包上,可以使用 safari 来调试,但对于未添加设备的开发证书或其他企业证书都是不能调试的。
- QuickJS 引擎并未实现调试协议,如果要支持 JS 调试,还需要接入方按需实现。
对于一个跨平台框架,如 React Native、Hippy、小程序而言,好的调试体验是是屏蔽不同系统之间的差异,都可用一个调试工具来进行 JS 调试。这时对于 iOS JSC 来说,就存在两个问题,一个是 iOS 包的限制,另一个是和 Android 调试的工具存在不一致,此时也有几种解决方式:
- 小程序/React Native:逻辑层的 JS 代码放到 PC 端的 Chrome Web Worker 中执行,使用到的渲染层同步/异步接口统一使用异步方式通过 websocket 传到手机端渲染。存在问题主要是 V8/JSC 引擎执行差异如日期、重复定义 props 等,还有同步接口转异步的性能损耗。
- Hippy:统一使用 Chrome DevTools Frontend 来调试 V8/JSC,中间搭建一层协议转换把 V8 转换为 JSC 的调试协议,再通过 iOS 提供的系统调试通道转发给 JSC。存在的问题主要是 JSC 调试对于 iOS 包的开发者证书限制。